iT邦幫忙

2026 iThome 鐵人賽

DAY 21
0
Software Development

手刻 Redis:用 Go 從零打造高效能高併發的記憶體資料庫系列 第 21

Day 21:背景 AOF 重寫 (BGREWRITEAOF) 的 Copy-on-Write 與 Go 實作

  • 分享至 

  • xImage
  •  

AOF 持久化是追加模式,也就是說系統跑越久,AOF 檔案就會越大。如果同一個 Key 被改了一萬次,AOF 就老老實實記下一萬條指令。

但實際上,歷史紀錄不重要,只有最後一次修改的當前狀態才是我們要的。

今天來看 Redis 怎麼處理 AOF 膨脹,順便自己實作一版 AOF 重寫(AOF Rewrite)


AOF 重寫的做法

AOF 重寫不是把舊 AOF 檔一行一行拿來分析,而是直接看記憶體裡目前最新的狀態。每個還活著的 Key 重新產生一條可以重建它的寫指令,例如 List 就輸出一條包含目前所有元素的 RPUSH,再寫進新的臨時 AOF 檔。

寫完後,用臨時檔案原子替換舊的 AOF 檔案。

舊 Aof 檔案 (內含 100 萬次修改日誌)  --- 丟棄
                                   
記憶體 DB (當前狀態) ---> [遍歷快照] ---> 生成新臨時 Aof ---> 原子替換

為什麼叫「背景重寫(Background Rewrite)」?

AOF 重寫是個需要遍歷整個資料庫的重型磁碟 I/O 操作。如果直接在main thread 中執行,會導致資料庫卡死(阻塞數秒至數分鐘),這對高併發的資料庫是不可接受的。

Redis 在 Linux 上使用了 fork 系統呼叫 生成子進程,利用作業系統的 Copy-on-Write (寫時複製,COW) 技術:

  • fork 後,子進程與主進程共享同一塊記憶體物理頁。
  • 如果主進程此時有新的寫入,作業系統會單獨為其複製該記憶體頁,子進程看見的記憶體依然是 fork 那個瞬間的靜態快照。
  • 子進程將快照安全地寫入臨時檔,主進程則繼續處理請求,這互不干擾。

在 Go 語言中實作無阻塞重寫

在 Go 語言中,因為 goroutine 的運行架構,我們無法直接像single-thread 的 C 程式那樣呼叫 fork(這會導致多個背景線程狀態混亂)。說實在的,一開始我還想硬幹找個套件來 fork,結果發現 Go 遇到 fork 後面根本一團糟,最後還是得乖乖走其他路。

Go 這裡沒辦法照 Redis 那樣直接走 fork,所以我改用 快照複製(Shallow Copy) 的方式,盡量縮短鎖住 DB 的時間:

實作步驟:

  1. 短暫獲取 RLock:加鎖只為了拷貝 map 中的 *entry 指標。這段很短,至少不會在寫檔期間一直卡住 DB。
  2. 釋放讀鎖:解鎖後,main thread 可以無阻礙地繼續執行客戶端寫入。
  3. 背景 goroutine寫檔案:在另一個goroutine 中,遍歷該指針快照,將各項資料結構還原為對應的 SETRPUSHHSETSADDZADD 命令寫入臨時檔案。
  4. 原子替換舊檔:利用作業系統的原子更名 os.Rename 替換舊 AOF。

程式碼實作:AOF 重寫邏輯

我們在 code/db/aof.go 中實作了 Rewrite() 方法:

func (a *AofLogger) Rewrite() error {
	tempFilename := a.filename + ".tmp"
	tempFile, err := os.OpenFile(tempFilename, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)
	if err != nil { return err }
	defer tempFile.Close()

	// 1. 複製記憶體快照(極短時間鎖定)
	a.dbEngine.mu.RLock()
	snapshot := make(map[string]*entry, len(a.dbEngine.data))
	for k, v := range a.dbEngine.data {
		if !v.isExpired() {
			snapshot[k] = v // 拷貝 entry 指標
		}
	}
	a.dbEngine.mu.RUnlock() // 立即解鎖

	writer := bufio.NewWriter(tempFile)

	// 2. 遍歷快照,寫入重建命令
	for key, entry := range snapshot {
		var cmd resp.Value
		// ... 寫入 EXPIRE 恢復指令 ...

		switch entry.dataType {
		case TypeString:
			cmd = resp.NewArray([]resp.Value{
				resp.NewBulkString([]byte("SET")),
				resp.NewBulkString([]byte(key)),
				resp.NewBulkString(entry.val.([]byte)),
			})
			_, _ = writer.Write(cmd.Marshal())

		case TypeList:
			// 遍歷雙向鏈結,轉換成 RPUSH 命令
			// ...

		case TypeHash:
			// 遍歷 map,轉換成 HSET 命令
			// ...
		}
		// ...
	}
	_ = writer.Flush()
	_ = tempFile.Sync()

	// 3. 替換舊檔案
	a.mu.Lock()
	defer a.mu.Unlock()
	_ = a.file.Close()
	err = os.Rename(tempFilename, a.filename) // 原子更名
	// ... 重新開啟 a.file ...
	return nil
}

這樣做的重點是把慢的 I/O 和 DB 鎖分開。說真的,只要鎖加對地方,效能其實不會太差。


跑起來看看

我們來試試看 AOF 重寫到底有沒有瘦身效果:

# 1. 啟動伺服器,瘋狂寫入同一個 Key
$ redis-cli
> SET mykey "a"
> SET mykey "b"
... 狂敲 100 次
> BGREWRITEAOF
# 預期回覆:Background append only file rewriting started

看看背景重寫前後的 AOF 檔案大小差別:

$ du -sh appendonly.aof*
12K    appendonly.aof      # 原本一堆冗餘的歷史紀錄
 4K    appendonly.aof.tmp  # 重寫完只剩最後一條 SET mykey 的乾淨快照

重寫完後,舊檔就被 .tmp 替換掉了,這就是背景快照發揮功效的時候啦!


總結

今天把 AOF 檔案膨脹的問題拆開,也用「快照拷貝 + 背景 goroutine」做了一版非同步 AOF 重寫。

明天來處理 RDB(二進位快照持久化),聽說要用 gob 來壓資料,感覺有點意思。


上一篇
Day 20:實作 AOF 的每秒非同步 fsync(everysec)機制
下一篇
Day 22:RDB (Redis Database) 快照持久化原理與binary serialization設計
系列文
手刻 Redis:用 Go 從零打造高效能高併發的記憶體資料庫25
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言